Dockerfile、BuildKit 与 Buildx
本篇围绕「怎么把应用打成镜像」展开:先用一个完整实例把常用指令串起来(用法都写在注释里),再讲多阶段构建、构建缓存,以及 BuildKit / Buildx 带来的新能力(secret mount、ssh mount、多架构构建)。日常查指令语法也可以看附录的《Dockerfile 指令速查》。
Dockerfile 基础
Dockerfile 是一个纯文本文件,按顺序描述「从哪个基础镜像开始、执行哪些操作、容器启动时跑什么命令」。docker build 会逐条执行里面的指令,每执行一条就生成一个镜像层,全部执行完得到一个可运行的镜像。
先写一个最小但完整的例子:用 nginx 托管一个静态页面。
目录结构:
myweb/
├── Dockerfile
└── index.html
Dockerfile 内容:
FROM nginx:alpine
# 把本地的 index.html 复制进 nginx 的站点目录
COPY index.html /usr/share/nginx/html/index.html
# 声明容器监听 80 端口(仅作文档说明,不做实际端口映射)
EXPOSE 80
构建并运行:
# -t 给镜像起名字和标签,结尾的 . 表示构建上下文是当前目录
docker build -t myweb:1.0 .
# 启动容器,把宿主机 8080 映射到容器 80
docker run -d --name myweb -p 8080:80 myweb:1.0
# 验证
curl http://localhost:8080
<h1>Hello Docker</h1>
构建上下文(build context)就是 docker build 最后那个参数指定的目录。Docker 会把整个目录发给构建器,COPY 的源路径只能取这个目录里的文件,取不到外面的。目录里不想被打包的东西(.git、node_modules、密钥文件)要写进 .dockerignore,后面「镜像优化」一节会讲。
一个实例看懂常用指令
与其逐条背指令语法,不如直接看一个完整例子。下面是一个 Node.js Web 应用的 Dockerfile,12 个常用指令全部用到,每条指令的用途和注意点都写在注释里。
项目结构:
myapp/
├── Dockerfile
├── package.json
├── package-lock.json
├── config.tar.gz # 随仓库分发的配置模板压缩包
└── server.js
server.js 是一个最小的 HTTP 服务,带健康检查接口:
const http = require('node:http')
const server = http.createServer((req, res) => {
if (req.url === '/healthz') {
res.writeHead(200)
return res.end('ok')
}
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end(`myapp ${process.env.APP_VERSION || 'dev'}`)
})
server.listen(3000)
完整的 Dockerfile:
# syntax=docker/dockerfile:1
# FROM:指定基础镜像,Dockerfile 里的一切都从它开始叠加,必须是第一条指令(前面只允许 ARG 和注释)。
# 选 node:22-alpine:官方镜像、固定大版本、alpine 变体体积小(约 130 MB)。
# 生产环境不要用 latest 标签——它会随时间指向不同版本,构建结果不可复现。
FROM node:22-alpine
# LABEL:给镜像加元数据(标题、源码地址等键值对),不影响构建产物。
# org.opencontainers.image.* 是 OCI 标准前缀,GitHub Container Registry 等仓库能自动识别展示。
LABEL org.opencontainers.image.title="myapp" \
org.opencontainers.image.source="https://github.com/example/myapp"
# ARG:构建期变量,只在 docker build 时有效,构建结束就没了,容器运行时读不到。
# 构建时用 --build-arg APP_VERSION=1.0.0 传入,不传就用默认值 dev。
# 注意不要用 ARG 传密钥——值会留在构建历史里,docker history 直接可见。
ARG APP_VERSION=dev
# ENV:环境变量,构建期(后续的 RUN)和运行期(容器里的进程)都能读到,永久留在镜像里。
# 这里把 ARG 的值转存给 ENV,应用运行时也能拿到版本号。同理,密码密钥也不要放进 ENV。
ENV NODE_ENV=production \
APP_VERSION=${APP_VERSION}
# WORKDIR:设置后续指令(RUN/COPY/CMD 等)的工作目录,目录不存在会自动创建。
# 不要用 RUN cd /app 代替——cd 只在那一条 RUN 内部有效,下一条指令又回到 /。
WORKDIR /app
# COPY:把构建上下文里的文件复制进镜像,源路径相对于上下文,不能用 ../ 跑出去。
# 先只拷依赖清单:清单没变就能命中层缓存,不用重装依赖(原理见「Build Cache」一节)。
COPY package.json package-lock.json ./
# RUN:构建时执行命令,装好的软件、生成的文件固化成一个新的镜像层。
# 装依赖和清缓存写在同一条 RUN 里——分两条写的话缓存已固化在前一层,后面删了也白删。
RUN npm ci --omit=dev \
&& npm cache clean --force
# ADD:和 COPY 一样复制文件,但多两个能力——本地 tar 包自动解压、源可以是 URL。
# 这里把配置模板包解压到 /app/config。官方建议能用 COPY 就别用 ADD,
# 只有确实需要自动解压时才用 ADD。
ADD config.tar.gz /app/config/
# 再拷应用代码。复制进来的文件默认属主是 root,--chown 顺便改成 node 用户。
COPY . .
# USER:之后的指令和容器进程都以 node 身份运行(node 官方镜像自带 node 用户,UID 1000)。
# 容器默认以 root 运行,应用一旦被攻破攻击者拿到的就是容器内 root,非 root 运行是最基本的安全基线。
USER node
# HEALTHCHECK:告诉 Docker 怎么判断应用是否健康,结果显示在 docker ps 的 STATUS 列。
# 退出码 0 健康、1 不健康;Node 18+ 内置 fetch,不依赖 curl/wget。
# --start-period 给启动慢的应用留宽限期,期间失败不计入失败次数。
HEALTHCHECK \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# ENTRYPOINT:容器启动时固定执行的入口程序,docker run 后跟的命令不会覆盖它,而是作为参数追加。
# 用 exec 表单(JSON 数组):node 直接成为 PID 1,能收到 docker stop 的 SIGTERM 优雅退出。
ENTRYPOINT ["node"]
# CMD:容器启动时默认执行的命令,docker run 后跟命令会把它整个覆盖。
# 和 ENTRYPOINT 组合时充当默认参数——默认执行 node server.js。
CMD ["server.js"]
构建并运行:
# --build-arg 传入构建期变量
docker build --build-arg APP_VERSION=1.0.0 -t myapp:1.0.0 .
docker run -d --name myapp -p 3000:3000 myapp:1.0.0
docker ps
CONTAINER ID IMAGE STATUS PORTS NAMES
a1b2c3d4e5f6 myapp:1.0.0 Up 2 minutes (healthy) 0.0.0.0:3000->3000/tcp myapp
docker run myapp:1.0.0 worker.js 会把 CMD 整个替换掉,实际执行 node worker.js——ENTRYPOINT 不变、CMD 被覆盖,这正是两者配合的用法。
注释里没有展开的几组易混淆点,补充辨析:
- COPY vs ADD:能用
COPY就别用ADD,只有确实需要自动解压本地 tar 包时才用。从 URL 下载文件也别用ADD——下载的文件会固化在层里删不掉,用RUN curl -fsSL ... && 用完删掉,下载和清理在同一层完成。 - CMD vs ENTRYPOINT:
docker run后跟的命令整个覆盖CMD、作为参数追加给ENTRYPOINT(想改入口要用docker run --entrypoint);写多条CMD只有最后一条生效。 - exec 表单 vs shell 表单:
["node", "server.js"]这种 exec 表单(JSON 数组),进程本身就是 PID 1,能收到docker stop的 SIGTERM;shell 表单(直接写命令)的 PID 1 是 sh,信号默认不转发,应用只能等 10 秒超时被强杀。必须用启动脚本时,脚本最后一行用exec启动主进程,让它顶替脚本成为 PID 1。 - ENV vs ARG:
ARG只在构建期有效,ENV构建期、运行期都在;运行期要用构建参数的值,就像实例里把ARG转存给ENV。另外ARG值变化会让使用它的指令及之后的层缓存全部失效,频繁变化的ARG尽量往 Dockerfile 后面放。 - FROM:基础镜像优先选 Docker Official Image(
node、nginx这类不带用户名前缀的),来源不明的第三方镜像可能藏着挖矿程序。 - HEALTHCHECK:检查失败只是标记状态,Docker 本身不会自动重启 unhealthy 的容器;Kubernetes 不读它,K8s 用 Pod 里的
livenessProbe/readinessProbe,两者不冲突。
多阶段构建
多阶段构建指在一个 Dockerfile 里写多个 FROM:前面的阶段负责编译构建,最后一个阶段只通过 COPY --from 把产物挑出来。镜像变小的原理:最终镜像只保留最后一个阶段产生的层,前面阶段的层(编译器、依赖缓存、中间产物)全部丢弃,编译工具链根本不出现在最终镜像里。
Go:编译 + distroless 运行——
# syntax=docker/dockerfile:1
# ---------- 构建阶段 ----------
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o server .
# ---------- 运行阶段 ----------
FROM gcr.io/distroless/static-debian12
COPY /app/server /server # 只把二进制挑出来
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
同一个 Go Web 程序,几种打包方式的体积对比(约数):
| 方案 | 镜像体积 |
|---|---|
单阶段 golang:1.23 | 900 MB+ |
单阶段 golang:1.23-alpine | 400 MB+ |
| 多阶段 + distroless static | 20 MB 左右 |
多阶段 + scratch | 10 MB 左右 |
scratch 里什么都没有——没有证书、没有时区数据、没有 /tmp,程序必须编译成完全静态链接的二进制(CGO_ENABLED=0)才能跑。distroless static 比 scratch 多了 CA 证书、时区、passwd 文件和一个 nonroot 用户,日常更省心,推荐优先用。
前端项目同理——Node 构建、nginx 托管,最终镜像里只有 nginx 和静态产物,node_modules、npm 缓存全部不进镜像:
# ---------- 构建阶段 ----------
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---------- 运行阶段 ----------
FROM nginx:alpine
COPY /app/dist /usr/share/nginx/html
EXPOSE 80
常用技巧:
- 阶段用
AS命名后按名字引用(COPY --from=builder),比按序号引用(COPY --from=0)稳定,调整阶段顺序时不会错位。 docker build --target builder .只构建到指定阶段为止,调试构建阶段的问题时很有用。COPY --from还可以引用外部镜像,从别的镜像里抠文件:COPY --from=nginx:alpine /etc/nginx/nginx.conf /nginx.conf。- 最终镜像里没有编译器、shell、包管理器,攻击面小了很多——体积和安全双赢。
Build Cache
层缓存机制:每条指令对应一个镜像层,构建时 Docker 逐条检查——这条指令和它依赖的上一层有没有变过,没变就直接复用,变了就重新执行,而且从这条开始,后面所有层的缓存一起作废(链式失效)。FROM、RUN 等比较指令字符串;COPY、ADD 计算文件内容的校验和,内容变了缓存就失效,只改文件名不行。
所以指令顺序决定构建速度。反面教材:
FROM node:22-alpine
WORKDIR /app
COPY . . # 任何一行代码改动,这层缓存就失效
RUN npm ci # 于是依赖每次都要重新安装,非常慢
CMD ["node", "server.js"]
正确写法——把不常变的「装依赖」放前面,常变的「拷代码」放后面:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # 只有依赖清单变了才触发重装
RUN npm ci
COPY . . # 日常改代码只影响这一层
CMD ["node", "server.js"]
# 完全不用缓存,从头构建(怀疑缓存有问题时用)
docker build --no-cache -t myapp .
# 只让某个阶段之后的缓存失效(--no-cache-filter 后跟阶段名)
docker buildx build --no-cache-filter=builder -t myapp .
cache mount:包管理器自己的下载缓存(npm 的 .npm、apt 的 /var/cache/apt 等)是个两难——留在镜像里占体积,清掉了下次构建又要全部重新下载。BuildKit 的 cache mount 给出第三条路:把这些目录挂到独立的缓存存储里,不写入镜像层、跨多次构建共享:
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
# npm 下载缓存放进 BuildKit 的缓存卷:不进镜像、下次构建直接复用
RUN \
npm ci
COPY . .
CMD ["node", "server.js"]
apt 同理,用 --mount=type=cache,target=/var/cache/apt,sharing=locked 挂载。挂载的目录在 RUN 结束后就被卸载、不进镜像层,所以用了 cache mount 就不必再写 rm -rf /var/lib/apt/lists/* 这类清理命令。注意这个语法依赖 BuildKit,记得首行写 # syntax=docker/dockerfile:1 声明。
BuildKit
BuildKit 是 Docker 的新一代镜像构建器,替代老的经典 builder。Docker 23.0 起 docker build 默认就走 BuildKit,不需要任何配置;老版本可以显式开启:DOCKER_BUILDKIT=1 docker build ...(或在 /etc/docker/daemon.json 里设 {"features": {"buildkit": true}})。
相比老 builder 的优势:
| 特性 | 老 builder | BuildKit |
|---|---|---|
| 执行方式 | 严格串行,逐条执行 | 分析依赖关系,无依赖的阶段可并行构建 |
| 多阶段构建 | 所有阶段全部构建 | 只构建目标阶段依赖的部分 |
| 缓存能力 | 仅本地层缓存 | cache mount、缓存导出/导入(--cache-to / --cache-from) |
| 敏感数据 | 只能靠 ARG/ENV,会泄露 | secret mount、ssh mount,不落盘 |
| 输出方式 | 只能产出镜像到本地 | 可导出 tar、目录、直接推 registry |
Dockerfile 第一行的 # syntax=docker/dockerfile:1 注释是给 BuildKit 看的:指定用哪个 Dockerfile 前端解析器。cache/secret/ssh mount 这些新语法特性都通过它启用,建议养成习惯写上。
Secret Mount
先讲为什么密钥不能进镜像层。镜像层一旦生成就不可变,写进层里的文件会被完整保留——即使后面的层把它 rm 删了,那也只是加了一条「删除标记」,文件内容还躺在下面的层里,docker save 导出或 docker history 翻历史就能找回来。ARG 传 token、COPY .npmrc 再删、ENV 写密码,全都中招。
正确做法是用 BuildKit 的 secret mount:密钥只在 RUN 执行的那一瞬间以文件形式挂载进来,用完即卸载,不落进任何镜像层。
完整例子——构建时访问私有 npm 仓库:
# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
# /root/.npmrc 只在这一条 RUN 执行期间存在
RUN \
npm ci
COPY . .
CMD ["node", "server.js"]
构建时通过 --secret 把密钥传进去,支持文件和环境变量两种方式:
# 方式一:从宿主机文件传入
docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
# 方式二:从环境变量传入(CI 里常用)
export NPMRC_CONTENT="$(cat ~/.npmrc)"
docker buildx build --secret id=npmrc,env=NPMRC_CONTENT -t myapp .
三个「不要」:不要把密钥写进 Dockerfile;不要 COPY 进镜像再删;不要用 --build-arg 传密钥。密钥只要进过镜像层,就永远在里面,删掉也没用。构建期用 secret mount,运行期用 docker run -e、K8s Secret 或密钥管理服务在启动时注入。
SSH Mount
场景:构建时要 git clone 私有仓库、go mod download 私有模块,需要用到你本地的 SSH 私钥。私钥当然不能 COPY 进镜像,BuildKit 的 ssh mount 可以把宿主机 ssh-agent 的 socket 临时挂进构建环境——构建过程能借用你的身份完成认证,私钥本身永远不离开宿主机。
完整例子——构建时拉私有仓库:
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git openssh-client
# 先把 github.com 的主机指纹写进 known_hosts,否则首次连接会卡在确认提示上
RUN mkdir -p -m 0700 /root/.ssh \
&& ssh-keyscan github.com >> /root/.ssh/known_hosts
# 这一条 RUN 期间可以使用宿主机的 ssh-agent
RUN \
git clone git@github.com:example/private-lib.git /src
WORKDIR /src
RUN go build -o /bin/app .
构建前先把私钥加载进 ssh-agent:
# 启动 agent 并加载私钥
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
# --ssh default 表示使用默认 agent,也可以显式指定 socket 路径
docker buildx build --ssh default=$SSH_AUTH_SOCK -t myapp .
注意:前提是你本机 ssh -T git@github.com 能认证成功;CI 环境(GitHub Actions 等)要先配合 webfactory/ssh-agent 这类 action 把 agent 起好,再执行构建。
Buildx
Buildx 是 BuildKit 能力的命令行入口,以 Docker CLI 插件的形式提供(docker buildx ...)。它把 docker build 升级成 docker buildx build,多出来的核心能力是:多 builder 实例、多架构构建、缓存导出、多样化输出。现在的 Docker 里 docker build 默认就走 buildx,平时用起来是无感的。
buildx 把「构建环境」抽象成 builder,可以创建多个相互隔离的实例:
# 创建一个叫 mybuilder 的实例并设为当前默认
docker buildx create --name mybuilder --driver docker-container --use
# 查看所有实例(带 * 号的是当前正在使用的)
docker buildx ls
# 启动并检查实例状态
docker buildx inspect --bootstrap
driver 的区别:
| driver | 说明 |
|---|---|
docker | 内置在 dockerd 里的构建器,无需创建,但功能受限(不能一次构建多个平台、不能导出缓存) |
docker-container | 在容器里跑一个独立的 BuildKit 进程,功能最全,多架构构建必须用它 |
kubernetes | 把构建任务分发到 K8s 集群,团队共享构建资源时使用 |
常用命令:
# 构建并加载到本地镜像列表(单平台时等价于 docker build)
docker buildx build -t myapp:1.0 --load .
# 构建完直接推送到仓库
docker buildx build -t <你的仓库>/myapp:1.0 --push .
# 只构建不落地,CI 里验证 Dockerfile 能不能构建通过
docker buildx build .
# 不用的实例删掉
docker buildx rm mybuilder
docker-container driver 构建出来的镜像存在 builder 容器里,本地 docker images 看不到,必须显式 --load(加载进本地)或 --push(推送到仓库)。平时 docker build 用起来无感,就是因为默认的 docker driver 帮你自动做了加载。
多架构镜像(amd64 / arm64)
背景:服务器大多是 x86_64(amd64),而 Apple Silicon Mac 和越来越多的 ARM 云服务器是 arm64。在 amd64 机器上构建的镜像拿到 arm64 机器上是跑不起来的。多架构镜像的做法是:同一个镜像名+标签下挂多个架构的镜像(技术上叫 manifest list / image index),docker pull 时 Docker 按本机架构自动选对的那一个。
跨架构构建靠 QEMU 模拟执行异架构指令。Docker Desktop 已经内置;Linux 上要手动注册一次 binfmt:
# 注册 QEMU 模拟器(一次性操作)
docker run --privileged --rm tonistiigi/binfmt --install all
# 确认 builder 支持的平台
docker buildx ls
完整流程——构建 amd64 + arm64 双架构镜像并推送到仓库:
# 1. 建一个 docker-container 驱动的 builder(默认 docker driver 干不了多架构)
docker buildx create --name mybuilder --driver docker-container --use
docker buildx inspect --bootstrap
# 2. 登录仓库
docker login
# 3. 一次构建两个平台并推送
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t <你的用户名>/myapp:1.0 \
--push .
# 4. 验证 manifest list:输出里能看到 linux/amd64 和 linux/arm64 两个条目
docker buildx imagetools inspect <你的用户名>/myapp:1.0
多平台构建的结果不能直接 --load 到本地——本地的 Docker 镜像存储只支持单平台,所以要直接 --push 到仓库。如果只想在本地试试某个平台的构建,单平台加 --load 是可以的:docker buildx build --platform linux/arm64 --load .。
QEMU 模拟的代价是慢:在 amd64 机器上模拟执行 arm64 指令,编译型任务可能慢数倍。对 Go 这类支持交叉编译的语言,更好的办法是配合 TARGETARCH 在本机架构上直接交叉编译,下一节讲。
TARGETARCH
做多架构构建时,Dockerfile 里经常需要知道「当前正在为哪个架构构建」,比如下载对应架构的预编译二进制。BuildKit 会自动注入一组构建参数(不需要 --build-arg 手动传),在 Dockerfile 里用 ARG 声明后即可使用:
| 变量 | 含义 | 示例值 |
|---|---|---|
TARGETPLATFORM | 目标平台 | linux/arm64 |
TARGETOS / TARGETARCH | 目标系统 / 架构 | linux / arm64 |
TARGETVARIANT | 架构变体 | arm/v7 时是 v7 |
BUILDPLATFORM | 执行构建的机器平台 | linux/amd64 |
BUILDOS / BUILDARCH | 构建机的系统 / 架构 | linux / amd64 |
按架构下载不同二进制(以 kubectl 为例,构建 arm64 镜像时就下载 arm64 的 kubectl):
# syntax=docker/dockerfile:1
FROM alpine:3.20
# 必须先 ARG 声明,后面的 RUN 里才用得到
ARG TARGETARCH
RUN apk add --no-cache curl \
&& curl -fsSL -o /usr/local/bin/kubectl \
"https://dl.k8s.io/release/v1.31.0/bin/linux/${TARGETARCH}/kubectl" \
&& chmod +x /usr/local/bin/kubectl
Go 交叉编译,比 QEMU 模拟快得多——构建阶段固定跑在构建机的原生架构上(不做模拟),让 Go 编译器交叉编译出目标架构的二进制:
# syntax=docker/dockerfile:1
# --platform=$BUILDPLATFORM:这个阶段始终用构建机的原生架构,不做模拟
FROM golang:1.23-alpine AS builder
ARG TARGETOS TARGETARCH
WORKDIR /app
COPY . .
# Go 交叉编译:本机 amd64 的编译器直接产出 arm64 二进制,速度几乎无损
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -o server .
FROM alpine:3.20
COPY /app/server /server
ENTRYPOINT ["/server"]
这样执行 docker buildx build --platform linux/amd64,linux/arm64 时,编译全程在 amd64 原生速度下跑,只是最后打包成两种架构的镜像,比 QEMU 模拟整个构建过程快非常多。
新手最常踩的坑是忘了写 ARG TARGETARCH 声明,结果变量展开成空字符串,下载地址 404 还排查半天。另外注意 FROM --platform=$BUILDPLATFORM 里变量前要带 $,因为 --platform 要的是变量的值。
镜像优化
减小镜像体积的清单,按收益从大到小排:
-
换小基础镜像。同一个 Node 应用,不同基础镜像能差好几倍:
基础镜像 体积(约) 特点 node:22(Debian 完整版)1.1 GB 工具全,调试图方便,生产偏大 node:22-slim(Debian 精简)220 MB 平衡之选 node:22-alpine130 MB musl libc,个别依赖 glibc 的包不兼容 distroless 视语言而定 无 shell 无包管理器,最安全,调试困难 scratch0 空镜像,只适合静态编译的二进制 -
多阶段构建:编译产物和编译工具链分离,见「多阶段构建」一节。
-
合并 RUN,减少层数:装包、配置、清理写在同一条
RUN里。 -
在同一层里清缓存:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*注意必须是同一层:分两条
RUN的话,apt 索引已经被前一条固化进层了,后一条删了也白删。 -
写好 .dockerignore:别把无关文件发进构建上下文——上下文大会拖慢构建,还可能把密钥文件打进镜像:
.git
node_modules
dist
*.log
.env
docker-compose*.yml -
拆分 COPY,利用层缓存:依赖清单先拷、代码后拷,见「Build Cache」一节。
-
不装用不到的包:apt 加
--no-install-recommends,alpine 的 apk 加--no-cache;构建完别留 vim、curl 这类调试工具(真要调试,用临时容器挂进去)。 -
固定依赖版本:基础镜像标签、apt 包、npm 包全部锁定,保证构建可复现。
体积之外:把最常变的指令放最后,镜像更新时只需拉取和重建最少的层;非 root 运行、distroless 缩小攻击面;打 LABEL 标注版本和源码地址,方便追溯。
企业级 Dockerfile 最佳实践
把前面各节的要求汇总成一份检查清单,写 Dockerfile 时逐条对照。
基础镜像
- 不用
latest,固定到具体版本标签;核心服务固定到 digest。 - 能用 alpine/slim 就不用完整版;Go 等静态编译语言优先考虑 distroless 或
scratch。 - 基础镜像来源可信:Docker Official Image,或企业内部统一维护、定期更新的基础镜像。
构建过程
- 有
.dockerignore,排除.git、node_modules、密钥文件、CI 配置。 - 依赖安装和代码
COPY分开,充分利用层缓存。 - 编译型语言用多阶段构建,最终镜像不含编译工具链。
- 构建期的密钥/私库凭证用 secret mount 或 ssh mount,绝不用
ARG/ENV传递。 - Dockerfile 第一行声明
# syntax=docker/dockerfile:1,启用新语法。
运行安全
- 创建专用用户,
USER切换为非 root 运行。 - 只安装运行必需的包,能不留 shell 就不留(distroless)。
-
CMD/ENTRYPOINT用 exec 表单,保证 PID 1 能收到 SIGTERM 优雅退出。 - 不在
ENV、镜像层里放任何敏感信息。
可观测与可维护
- 配置
HEALTHCHECK(K8s 场景由 probe 接管)。 - 打 OCI 标准
LABEL:title、version、source、revision。 - 应用日志输出到 stdout/stderr,不写容器内文件。
- 镜像在 CI 里统一构建和推送;版本号、commit 用
--build-arg注入,并写入LABEL便于追溯。
持续治理
- 定期重建镜像,即使代码没变——基础镜像的安全补丁要靠重建才能拿到。
- 接入镜像扫描(trivy、docker scout、registry 自带扫描),高危漏洞阻断发布。
- 需要跨架构部署的,用 buildx 统一构建 amd64/arm64 并推送 manifest list。
这份清单可以落到团队的代码评审 checklist 或 CI 流水线里。也可以配合 hadolint 这类 Dockerfile lint 工具,把「合并 RUN、固定版本、非 root 运行」等规则变成自动化检查,比人肉 review 可靠。